Skip to content

Accept a pasted Linear URL wherever an identifier goes - #291

Merged
schpetbot merged 1 commit into
mainfrom
linear-urls
Sep 23, 2026
Merged

schpetbot merged 1 commit into
mainfrom
linear-urls

Conversation

@schpetbot

@schpetbot schpetbot commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Copying a link out of Linear and pasting it into the CLI is the obvious way to refer to something, and it did not work.

linear issue view <issue url> failed outright with "Could not determine issue ID". Project and document URLs appeared to work, but only because Linear's API quietly extracts a slug when a whole URL is handed to a slugId filter — undocumented behavior the CLI should not lean on, and one that produces a bare not found: https://… the moment the URL points at anything else.

What changed

A new pure module classifies a reference as one of three things:

  • ordinary input — falls through untouched, so every existing ID, slug, name and UUID lookup is unchanged;
  • a Linear URL naming something the CLI can look up — extracted locally and never sent to GraphQL as a URL;
  • a Linear URL it cannot use — reported, rather than retried as a name. This is what turns /settings from a mysterious missing project into a sentence.

Wiring it into the shared resolvers covers most of the surface at once, since positional arguments and flags like --project, --parent and --team already funnel through them. The rest had to be found: ten commands define their own local resolver shadowing the shared one, and five document commands pass an id straight to document(id:).

linear issue view https://linear.app/acme/issue/ENG-123/some-title
linear project view https://linear.app/acme/project/mobile-launch-272f50ef9250
linear issue update ENG-1 --parent https://linear.app/acme/issue/ENG-7/x

Scheme optional; query strings and title slugs ignored.

Errors

Input Before Now
issue URL to project view Project not found: https://… is an issue URL, not a project URL
URL from another workspace resolved, or not-found names both workspaces, suggests --workspace
/settings URL Project not found: https://… "settings" is not an entity this command can use
milestone / label / template not found: https://… this command does not take one

Checked against the real app

After the first version, the URL shapes were checked by hand against URLs copied out of the Linear app, and three things changed:

  • Cycle URLs are now supported. The first version refused them, because Cycle has no url field and the shape was a guess. The app turned out to produce three forms: /team/CLI/cycle/5, /cycle/active and /cycle/upcoming. The last one is what the CLI already calls next. /cycle/4 and /cycle/6 were opened by hand to confirm the number round-trips. A cycle URL carries its team: cycle view <url> uses it, and a --team naming a different team is refused, because resolving the number against the wrong team finds a plausible-looking wrong cycle. The number is matched as a number only. The general lookup also matches cycle names, so a cycle named "7" could otherwise win, and there's a test that fails under the old matching.
  • Two team pages were missing. overview (the team's home page, and probably the most-copied team URL) and views/issues would have been refused.
  • Confirmed to have no URL: milestones, labels and templates. They keep refusing. The app's "copy link" for a template produces /team/CLI/new?template=<uuid>, but that was judged not common enough to support.

The app's Copy link matches the API's url field for issues, projects and comments, so nothing needed handling there.

Comment IDs are still refused. The #comment- anchor keeps only the first 8 characters of the UUID. The issue is in the path, so an issue-scoped prefix search looked feasible, but linear.ts fetches comments(first: 50) with no pagination. A lookup that can silently miss a match on page two shouldn't back issue comment delete. A comment URL still names its issue, so issue view takes one.

Every identifier slot, swept

Review kept finding slots that passed a pasted URL straight to GraphQL, one at a time, so every slot was swept live with a URL that names nothing. Each one now either resolves the URL or says it doesn't take one: --label (issue and project create/update), --reply-to on all four comment-add commands, milestone update/delete (refused before the delete confirmation prompt), issue agent-session view, --assignee/--lead, and --state.

issue link is untouched

A lone URL argument there already means the thing being linked, not the issue to link it to — and a Linear URL is a perfectly reasonable thing to attach. That branch is unchanged and locked by a regression test.

Testing

Most behavior is pure and covered by a table seeded from the url values Linear's own API returns, plus lookalike hosts (linear.app.evil.example), ports, credentials, scheme-less, www, http, casing, query strings, and unknown paths. Resolver tests prove the extracted value reaches GraphQL rather than the URL.

Live-QA'd against the real API across 15 cases. Two defects were found there and fixed: label delete and template view were missed in the first pass, and the workspace guard never fired because it had been written to stay silent under a raw LINEAR_API_KEY.

Known, pre-existing, not fixed here

Seven initiative commands (view, update, delete, archive, unarchive, add-project, remove-project) call their resolver before the action's try, so any resolution error prints a stack trace instead of a clean message. This is pre-existing — linear initiative view some-garbage-name stack-traces on main today — but this change makes the path easier to hit. Fixing it means restructuring seven action bodies, which did not belong in this commit. Happy to do it as a follow-up.

@schpetbot
schpetbot force-pushed the linear-urls branch 2 times, most recently from 102a2b7 to 55bcd49 Compare September 23, 2026 05:23
Copying a link out of Linear and pasting it into the CLI is the obvious way to
refer to something, and it did not work. `issue view <url>` failed outright with
"Could not determine issue ID". Project and document URLs appeared to work, but
only because Linear's API quietly extracts a slug when a whole URL is handed to
a `slugId` filter -- undocumented behavior the CLI should not lean on, and one
that produces a bare "not found: https://..." the moment the URL points at
anything else.

Parse the URL locally instead. A new module classifies a reference as ordinary
input, a Linear URL naming something the CLI can look up, or a Linear URL it
cannot use. The first falls through untouched, so every existing ID, slug, name
and UUID lookup is unchanged -- and so is `issue link`, where a lone URL
argument still means the thing being linked rather than the issue to link it to.
The third is reported rather than retried as a name, which is what turns
`/settings` from a mysterious missing project into a sentence.

Wiring it into the shared resolvers covers most of the surface at once, since
positional arguments and flags like --project, --parent and --team already
funnel through them. The rest needed finding: ten commands define their own
local resolver shadowing the shared one, and five document commands pass an id
straight to `document(id:)`. Each gets the same extraction.

A URL for the wrong kind of thing now names what it actually points at, and one
from another workspace names both. The workspace comparison trusts the
configured workspace even under a raw LINEAR_API_KEY, where the key's
organization is not knowable locally: refusing to guess there would disable the
check for the most common setup, which is exactly when a URL from the wrong
workspace gets pasted.

Cycle URLs are supported in the three forms the app actually produces,
copied out of it rather than guessed: /team/ENG/cycle/5, /cycle/active and
/cycle/upcoming, the last being what the CLI already calls `next`. Cycle has
no url field in the schema, so these could only come from the app. A cycle
URL carries its team, so `cycle view <url>` uses it; a --team naming a
different team is refused, since resolving the URL's number against the wrong
team finds a plausible-looking wrong cycle. The number is matched as a number
only -- the general lookup also matches cycle names, so a cycle named "7" could
otherwise win.

Comment IDs are deliberately refused rather than guessed: the #comment- anchor
keeps only the first eight characters of the UUID. The issue is in the path, so
an issue-scoped prefix search looked feasible, but the issue query fetches
comments(first: 50) with no pagination, and a lookup that can silently miss a
match on page two has no business backing `issue comment delete`. A comment URL
still names its issue, so `issue view` takes one.

A URL resolves by slug alone, never through the name-first lookups: a
twelve-hex slug ID is also a legal project name, and the general project
lookup tries names first, so a project named after another project's slug
would otherwise win -- and `project delete <url>` would delete it. The strict
lookup keeps each command's archived semantics, so `initiative unarchive <url>`
still finds the archived initiative it exists to act on.

Project URLs are accepted from any of the project's tabs (/overview, /issues,
/updates, /activity), since that is usually where one is copied from.

The parser is also covered by property-based tests (fast-check, added as a
test-only dependency). They generate references, render them the ways people
paste them, and check they parse back; that only linear.app itself is ever
read as Linear, with the WHATWG URL parser as the reference; that ordinary IDs
and names are never mistaken for URLs; and that junk and damaged URLs never
throw anything but a ValidationError. A 600,000-case campaign found nothing.
To show that is evidence rather than a toothless fuzzer, three bugs were
planted in turn -- a loosened host check, slug extraction from the wrong dash,
an unguarded percent-decode -- and each was caught and shrunk to a minimal
counterexample. Every run uses a fresh seed; a failure prints the seed, and
FC_SEED/FC_PATH replay it.
@schpetbot
schpetbot merged commit d4fe6fa into main Sep 23, 2026
15 checks passed
@schpetbot
schpetbot deleted the linear-urls branch September 23, 2026 05:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants